/*
This softtware use BSD licence (http://opensource.org/licenses/BSD-3-Clause)

Copyright (c) 2013, Martin Lebeda
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the Martin Lebeda nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package cz.lebeda.bas;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.filefilter.FileFilterUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * Provide repository access.
 *
 * @author <a href="mailto:martin.lebeda@gmail.com">Martin Lebeda</a>
 *         Date: 11.11.13
 */
public class Repository {

    public static final String DATA_DIR = "data";
    public static final String META_DIR = "meta";
    public static final int MAX_COMPRESS = 512 /* MB */ * 1024 * 1024;

    final Logger logger = LoggerFactory.getLogger(Repository.class);

    /**
     * Path to repository on disk (usualy ower net or USB drive etc.)
     */
    private Path repoPath;

    /**
     * Path to repository on disk as java File object (usualy ower net or USB drive etc.)
     */
    private File repoFile;

    /**
     * Cipher provider for data encryption/decryption
     */
    private CipherProvider dataCipherProvider;

    /**
     * Cipher provider for metadata (index of backup) encryption/decryption
     */
    private CipherProvider metaCipherProvider;

    /**
     * Create provider for low level repository access.
     *
     * @param repoPathStr path to repository on disk (usualy ower network or to removable device)
     * @param dataCipherProvider Cipher provider for data encryption/decryption
     * @param metaCipherProvider Cipher provider for metadata (index of backup) encryption/decryption
     */
    public Repository(String repoPathStr, CipherProvider dataCipherProvider, CipherProvider metaCipherProvider) {
        Validate.notBlank(repoPathStr, "Path to repository is blank.");

        this.dataCipherProvider = dataCipherProvider;
        this.metaCipherProvider = metaCipherProvider;

        this.repoPath = Paths.get(repoPathStr);
        this.repoFile = new File(repoPathStr);

        Validate.isTrue(!Files.exists(repoPath) || Files.isDirectory(repoPath), "Path to repository is not directory.");

        if (!Files.isDirectory(repoPath)) {
            try {
                final boolean mkdirsResult = repoFile.mkdirs();
                createReadme();

                if (mkdirsResult) {
                    logger.info("New repository '{}' created.", repoFile.getAbsolutePath());
                } else {
                    throw new IllegalStateException("Cannot create new repository directory");
                }
            } catch (Exception e) {
                throw new IllegalStateException("Cannot create new repository.");
            }
        }
    }

    /**
     * Create README file with warning about directory in new repository.
     *
     * @throws IOException
     */
    private void createReadme() throws IOException {
        final FileOutputStream outputStream =
                new FileOutputStream(Paths.get(repoFile.getAbsolutePath(), "README.txt").toString());
        outputStream.write(("This is backup reporsitory.  "
                + "Please don't change anything by hand if really know what you do.\n").getBytes());
        outputStream.close();
    }

    /**
     * @return absolute path to repository
     */
    public String getFullPath() {
        return repoFile.getAbsolutePath();
    }

    /**
     * Method for save content of backuped file to repository.
     *
     * @param voBackupFile prepared metadata about backuped file
     * @param oldFileSet list of already backuped files for check and add new content checksum
     * @throws IOException
     */
    public void saveContentToRepo(VOBackupFile voBackupFile, Set<VOBackupFile> oldFileSet) throws IOException {
        logger.debug("File {} begin to store.", voBackupFile.getPath());

        Map<String, String> fileHashIndex = new HashMap<>(oldFileSet.size());
        for (VOBackupFile backupFile : oldFileSet) {
            fileHashIndex.put(backupFile.getFileHash(), backupFile.getCheckSumBck());
        }

        String chsum = getMd5Sum(voBackupFile.getPath());
        logger.trace("File have checksum {}.", chsum);

        // create tgtDir
        Path tgtDir = getRepoDataPath(chsum);
        File tgtDirFile = tgtDir.toFile();
        if (!tgtDirFile.exists()) {
            final boolean mkdirs = tgtDirFile.mkdirs();
            if (!mkdirs) {
                throw new IllegalStateException("Cannot create repository subdir: " + tgtDirFile.getAbsolutePath());
            }
        }

        // create target file
        String baseFileName = chsum + "_" + voBackupFile.getSize();
        logger.trace("File have size {}.", voBackupFile.getSize());
        boolean isCompress = false;
        if (doCompress(voBackupFile)) {
            baseFileName += ".bz2";
            isCompress = true;
        }
        if (dataCipherProvider.isEncryptEnabled()) {
            baseFileName += ".enc";
        }
        Path tgtFilePath = Paths.get(tgtDir.toString(), baseFileName);
        File tgtFile = tgtFilePath.toFile();

        if (!tgtFile.exists()) {
            TmpContent tmpContent = new TmpContent(voBackupFile);
            OutputStream fos = tmpContent.getOutputStrem();

            // wrap if encrypt
            if (dataCipherProvider.isEncryptEnabled()) {
                fos = new CipherOutputStream(fos, dataCipherProvider.getEncryptCipher());
            }

            // wrap if compress
            if (isCompress) {
                fos = new BZip2CompressorOutputStream(fos);
            }

            InputStream fis = new FileInputStream(voBackupFile.getPath());
            if (tmpContent.isLarge()) {
                logger.trace("File prepare started.");
                IOUtils.copy(fis, fos);
            } else {
                logger.trace("File large prepare started.");
                byte[] buffer = new byte[TmpContent.BUFFER_SIZE];
                IOUtils.copyLarge(fis, fos, buffer);
            }
            fos.close();
            fis.close();
            logger.trace("File prepared.");

            // try read whole file
            String md5sumTarget = tmpContent.getMd5Sum();
            voBackupFile.setCheckSumBck(md5sumTarget);
            logger.trace("File checkSum computed.");
            fileHashIndex.put(baseFileName, md5sumTarget);

            // try to read and check and commit (= rename)
            final String tmpFileName = tgtFile.getAbsolutePath() + ".part";
            final InputStream copyFis = tmpContent.getInputStream();
            final FileOutputStream copyFos = new FileOutputStream(tmpFileName);
            if (tmpContent.isLarge()) {
                logger.trace("File copy started.");
                IOUtils.copy(copyFis, copyFos);
            } else {
                logger.trace("File large copy started.");
                byte[] buffer = new byte[TmpContent.BUFFER_SIZE];
                IOUtils.copyLarge(copyFis, copyFos, buffer);
            }
            copyFis.close();
            copyFos.close();
            tmpContent.clear();
            logger.trace("File copied.");
            File tmpFile = new File(tmpFileName);

            // rename (= commit to repository)
            final boolean renamedTo = tmpFile.renameTo(tgtFile);
            if (!renamedTo) {
                throw new IllegalStateException("Cannot rename temporary file to target: "
                        + tmpFile.getAbsolutePath() + " to "
                        + tgtFile.getAbsolutePath());
            }
            logger.trace("File renamed.");

            // store control checksum by backuped file
            final String tgtFileMd5Name = tgtFile.getAbsolutePath() + ".md5";
            final File tgtFileMd5 = new File(tgtFileMd5Name);
            FileUtils.writeStringToFile(tgtFileMd5, md5sumTarget, false);

            logger.debug("File {} was stored as {}", voBackupFile.getPath(), baseFileName);
            voBackupFile.setFileHash(baseFileName);
        } else {
            logger.debug("File {} was skiped for {}", voBackupFile.getPath(), baseFileName);
            voBackupFile.setFileHash(baseFileName);

            if (fileHashIndex.containsKey(baseFileName)) {
                logger.debug("Target checksum is loaded from old index.");
                voBackupFile.setCheckSumBck(fileHashIndex.get(baseFileName));
            } else {
                final String tgtFileMd5Name = tgtFile.getAbsolutePath() + ".md5";
                final File tgtFileMd5 = new File(tgtFileMd5Name);
                if (tgtFileMd5.exists()) {
                    final String tgtMd5Sum = FileUtils.readFileToString(tgtFileMd5);
                    fileHashIndex.put(baseFileName, tgtMd5Sum);
                    logger.debug("Target checksum is loaded from repository.");
                    voBackupFile.setCheckSumBck(tgtMd5Sum);
                } else {
                    logger.debug("Target checksum is computing from {}.", tgtFile.getAbsolutePath());
                    final String tgtMd5Sum = getMd5Sum(tgtFile.getAbsolutePath());
                    logger.debug("Target checksum is computed from {}.", tgtFile.getAbsolutePath());
                    voBackupFile.setCheckSumBck(tgtMd5Sum);
                    FileUtils.writeStringToFile(tgtFileMd5, tgtMd5Sum, false);
                    fileHashIndex.put(baseFileName, tgtMd5Sum);
                }
            }
        }

    }

    private Path getRepoDataPath(String fileNameHash) {
        return Paths.get(repoFile.getAbsolutePath(), DATA_DIR,
                getChar(fileNameHash, 0), getChar(fileNameHash, 1), getChar(fileNameHash, 2), getChar(fileNameHash, 3),
                getChar(fileNameHash, 4));
    }

    private static String getChar(String chsum, int i) {
        return String.valueOf(chsum.charAt(i));
    }

    static String getMd5Sum(String fileName) throws IOException {
        InputStream fis = new FileInputStream(fileName);
        String sumMd5 = DigestUtils.md5Hex(fis);
        fis.close();
        return sumMd5;
    }

    static boolean doCompress(VOBackupFile file) {
        String filenameLowerCase = StringUtils.lowerCase(file.getPath());
        return !StringUtils.endsWithAny(filenameLowerCase, new String[] {".zip",
                ".mp4", ".mp4", ".avi", ".mkv", ".mp3", ".flv",
                ".jpg",
                ".rar", ".7z", ".bz2", ".gz", ".jar",
                ".docx", ".xlsx", ".pptx",
                ".odt", ".ods", ".odg", ".odp"});
//                && (file.getSize() < MAX_COMPRESS);
    }

    public String saveBackupIndex(VOBackupIndex backupIndex, String idBackup) throws Exception {
        Serializer serializer = new Persister();

        // create meta dir
        Calendar now = Calendar.getInstance();

        Path tgtDir = getMetaPath(idBackup, now, repoFile.getAbsolutePath());
        File tgtDirFile = tgtDir.toFile();
        if (!tgtDirFile.exists()) {
            final boolean mkdirs = tgtDirFile.mkdirs();
            if (!mkdirs) {
                throw new IllegalStateException("Cannot create repository subdir: " + tgtDirFile.getAbsolutePath());
            }
        }

        // create index filename
        SimpleDateFormat bckFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
        String indexFileName = idBackup + "_" + bckFormat.format(now.getTime()) + ".xml.bz2";
        if (metaCipherProvider.isEncryptEnabled()) {
            indexFileName += ".enc";
        }
        Path indexFilePath = Paths.get(tgtDir.toString(), indexFileName);

        // prepare index
        ByteArrayOutputStream bfos = new ByteArrayOutputStream(TmpContent.BUFFER_SIZE);
        OutputStream fos = bfos;
        if (metaCipherProvider.isEncryptEnabled()) {
            fos = new CipherOutputStream(fos, metaCipherProvider.getEncryptCipher());
        }
        fos = new BZip2CompressorOutputStream(fos);

        logger.trace("index serialize start");
        serializer.write(backupIndex, fos);
        logger.trace("index serialize end");
        fos.close();

        // write index into target file
        logger.trace("index write start");
        InputStream tgtFis = new ByteArrayInputStream(bfos.toByteArray());
        OutputStream tgtFos = new FileOutputStream(indexFilePath.toFile());
        IOUtils.copy(tgtFis, tgtFos);
        tgtFis.close();
        tgtFos.close();
        logger.trace("index write end");

        logger.info("Saved backup index: {}.", indexFileName);
        return indexFileName;
    }

    private Path getMetaPath(String idBackup, Calendar now, final String repoFileAbsolutePath) {
        SimpleDateFormat bckDirFormat = new SimpleDateFormat("yyyyMM");
        return Paths.get(repoFileAbsolutePath, META_DIR, idBackup,
                String.valueOf(now.get(Calendar.YEAR)),
                bckDirFormat.format(now.getTime()));
    }

    /**
     * Basic check of index.
     *
      * @param backupIndex index to check
     * @param checkExistsFile check backuped content is as file in repository
     * @param checkFileCheckSum check checksum of content is as file in repository
     * @return sumary of check
     */
    public VOCheckIndex checkIndex(VOBackupIndex backupIndex,
                                   final boolean checkExistsFile,
                                   final boolean checkFileCheckSum) {
        VOCheckIndex result = new VOCheckIndex();

        if (CollectionUtils.isNotEmpty(backupIndex.getBackupFileList())) {
            for (VOBackupFile voBackupFile : backupIndex.getBackupFileList()) {
                if (!voBackupFile.isDirectory() && (voBackupFile.getSize() > 0)) {
                    if (StringUtils.isNotBlank(voBackupFile.getFileHash())) {
                        Path dataPath = getRepoDataPath(voBackupFile.getFileHash());

                        if (checkExistsFile) {
                            if (!Files.exists(dataPath)) {
                                result.addNonExistData(voBackupFile.getPath());
                            }
                        }

                        if (checkFileCheckSum) {
                            if (StringUtils.isNotBlank(voBackupFile.getCheckSumBck())) {
                                // TODO Lebeda - implement check checksum
                            } else {
                                result.addNonExistDataCheckSum(voBackupFile.getPath());
                            }
                        }
                    } else {
                        result.addNonExistDataLink(voBackupFile.getPath());
                    }
                }
            }
        }

        return result;
    }

    public Set<VOBackupFile> getOldBackupIndex(String idBackup) throws Exception {
        Set<VOBackupFile> result = new HashSet<>();

        Path pathForMeta = Paths.get(repoFile.getAbsolutePath(), META_DIR, idBackup);
        if (pathForMeta.toFile().isDirectory()) {

            //noinspection unchecked
            final Collection<File> fileColection = FileUtils.listFiles(pathForMeta.toFile(),
                    FileFilterUtils.or(
                            FileFilterUtils.suffixFileFilter(".xml.bz2"),
                            FileFilterUtils.suffixFileFilter(".xml.bz2.enc"))
                    ,
                    FileFilterUtils.trueFileFilter()
            );

            result.addAll(collectBackupedFilesFromIndex(fileColection));
        }

        // TODO Lebeda - log with count
        return result;
    }


    /**
     * Load allready backuped files from existing indexes.
     *
     * @param bckIdexCollection existing indexes
     * @return allready backuped files
     * @throws Exception
     */
    public Set<VOBackupFile> collectBackupedFilesFromIndex(Collection<File> bckIdexCollection) throws Exception {
        Set<VOBackupFile> result = new HashSet<>();
        for (File file : bckIdexCollection) {
            logger.trace("found index {}", file.getAbsolutePath());
            VOBackupIndex backupIndex = getVoBackupIndex(file);

            logger.trace("adding {} items", backupIndex.getBackupFileList().size());
            result.addAll(backupIndex.getBackupFileList());
            logger.trace("items was added");
        }
        return result;
    }

    public VOBackupIndex getVoBackupIndex(File file) throws Exception {
        logger.trace("loading file");
        String content = getStringFromIndexFiles(file);
        logger.trace("parsing file");

        Serializer serializer = new Persister();
        VOBackupIndex backupIndex = serializer.read(VOBackupIndex.class, content);
        logger.trace("parsed");
        return backupIndex;
    }

    private String getStringFromIndexFiles(File file) throws IOException {
        InputStream fis = new FileInputStream(file);
        if (StringUtils.endsWith(file.getAbsolutePath(), ".bz2.enc")) {
            if (metaCipherProvider.isEncryptEnabled()) {
                fis = new CipherInputStream(fis, metaCipherProvider.getDecryptCipher());
            } else {
                throw new IllegalStateException("Cannot decrypt index file");
            }
        }
        fis = new BZip2CompressorInputStream(fis);
        final List<String> stringList = IOUtils.readLines(fis, "UTF-8");
        fis.close();
        return StringUtils.join(stringList, "\n");
    }

    public void restoreFile(VOBackupFile voBackupFile, final String path) throws Exception {
        String fileNameHash = voBackupFile.getFileHash();
        Path tgtDir = getRepoDataPath(fileNameHash);
        final File tgtFile = Paths.get(tgtDir.toString(), fileNameHash).toFile();


        if (tgtFile.exists()) {
            InputStream fis = new FileInputStream(tgtFile);

            // wrap if encrypt
            // TODO Lebeda - ošetřit šifrování
            if (fileNameHash.contains(".enc")) {
                // TODO Lebeda - ošetřit zašifrovaný a nepředáno heslo
                fis = new CipherInputStream(fis, dataCipherProvider.getDecryptCipher());
            }

            // wrap if compress
            if (fileNameHash.contains(".bz2")) {
                fis = new BZip2CompressorInputStream(fis);
            }

            // create parent
            File file = new File(path);
            File parent = file.getParentFile();
            parent.mkdirs();

            // unpack file
            FileOutputStream fos = new FileOutputStream(path);
            IOUtils.copy(fis, fos);
            fos.close();
            fis.close();
            // TODO Lebeda - ověřit správné rozbalení

            file.setLastModified(voBackupFile.getModify().getTime());

            logger.debug("restored file: {}", path);

        } else {
            // TODO Lebeda - ošetřit neexistující soubor
        }
    }


    /**
     * @return get path for indexes
     */
    public File getMetaPath() {
        return Paths.get(repoFile.getAbsolutePath(), META_DIR).toFile();
    }
}
